package org.glowacki;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.log4j.Logger;

/**
 * Date parser state.
 */
class ParserState {
    /** bit indicating that the year comes before the month. */
    static final int YEAR_BEFORE_MONTH = 0x4;
    /** bit indicating that the year comes before the day. */
    static final int YEAR_BEFORE_DAY = 0x2;
    /** bit indicating that the month comes before the day. */
    static final int MONTH_BEFORE_DAY = 0x1;

    /** bit indicating that the year comes after the month. */
    static final int YEAR_AFTER_MONTH = 0x0;
    /** bit indicating that the year comes after the day. */
    static final int YEAR_AFTER_DAY = 0x0;
    /** bit indicating that the month comes after the day. */
    static final int MONTH_AFTER_DAY = 0x0;

    /** value indicating an unset variable. */
    static final int UNSET = Integer.MIN_VALUE;

    /** <tt>true</tt> if year should appear before month. */
    private boolean yearBeforeMonth;
    /** <tt>true</tt> if year should appear before day. */
    private boolean yearBeforeDay;
    /** <tt>true</tt> if month should appear before day. */
    private boolean monthBeforeDay;

    /** year. */
    private int year;
    /** month (0-11). */
    private int month;
    /** day of month. */
    private int day;
    /** hour (0-23). */
    private int hour;
    /** minute (0-59). */
    private int minute;
    /** second (0-59). */
    private int second;
    /** millisecond (0-999). */
    private int milli;

    /** <tt>true</tt> if time is after noon. */
    private boolean timePostMeridian;

    /** time zone (use default time zone if this is <tt>null</tt>). */
    private TimeZone timeZone;

    /**
     * Create parser state for the specified order.
     * @param order <tt>YY_MM_DD</tt>, <tt>MM_DD_YY</tt>, etc.
     */
    ParserState(int order) {
        yearBeforeMonth = (order & YEAR_BEFORE_MONTH) == YEAR_BEFORE_MONTH;
        yearBeforeDay = (order & YEAR_BEFORE_DAY) == YEAR_BEFORE_DAY;
        monthBeforeDay = (order & MONTH_BEFORE_DAY) == MONTH_BEFORE_DAY;

        year = UNSET;
        month = UNSET;
        day = UNSET;
        hour = UNSET;
        minute = UNSET;
        second = UNSET;
        timePostMeridian = false;
    }

    /**
     * Get day of month.
     * @return day of month
     */
    int getDate() {
        return day;
    }

    /**
     * Get hour.
     * @return hour
     */
    int getHour() {
        return hour;
    }

    /**
     * Get millisecond.
     * @return millisecond
     */
    int getMillisecond() {
        return milli;
    }

    /**
     * Get minute.
     * @return minute
     */
    int getMinute() {
        return minute;
    }

    /**
     * Get month.
     * @return month
     */
    int getMonth() {
        return month;
    }

    /**
     * Get second.
     * @return second
     */
    int getSecond() {
        return second;
    }

    /**
     * Get time zone.
     * @return time zone (<tt>null</tt> if none was specified)
     */
    TimeZone getTimeZone() {
        return timeZone;
    }

    /**
     * Get year.
     * @return year
     */
    int getYear() {
        return year;
    }

    /**
     * Is day of month value set?
     * @return <tt>true</tt> if a value has been assigned
     */
    boolean isDateSet() {
        return (day != UNSET);
    }

    /**
     * Is hour value set?
     * @return <tt>true</tt> if a value has been assigned
     */
    boolean isHourSet() {
        return (hour != UNSET);
    }

    /**
     * Is millisecond value set?
     * @return <tt>true</tt> if a value has been assigned
     */
    boolean isMillisecondSet() {
        return (milli != UNSET);
    }

    /**
     * Is minute value set?
     * @return <tt>true</tt> if a value has been assigned
     */
    boolean isMinuteSet() {
        return (minute != UNSET);
    }

    /**
     * Is a numeric month placed before a numeric day of month?
     * @return <tt>true</tt> if month is before day of month
     */
    boolean isMonthBeforeDay() {
        return monthBeforeDay;
    }

    /**
     * Is month value set?
     * @return <tt>true</tt> if a value has been assigned
     */
    boolean isMonthSet() {
        return (month != UNSET);
    }

    /**
     * Is second value set?
     * @return <tt>true</tt> if a value has been assigned
     */
    boolean isSecondSet() {
        return (second != UNSET);
    }

    /**
     * Is the time post-meridian (i.e. afternoon)?
     * @return <tt>true</tt> if time is P.M.
     */
    boolean isTimePostMeridian() {
        return (timePostMeridian || hour > 12);
    }

    /**
     * Is a numeric year placed before a numeric day of month?
     * @return <tt>true</tt> if year is before day of month
     */
    boolean isYearBeforeDay() {
        return yearBeforeDay;
    }

    /**
     * Is a numeric year placed before a numeric month?
     * @return <tt>true</tt> if year is before month
     */
    boolean isYearBeforeMonth() {
        return yearBeforeMonth;
    }

    /**
     * Is year value set?
     * @return <tt>true</tt> if a value has been assigned
     */
    boolean isYearSet() {
        return (year != UNSET);
    }

    /**
     * Fill the calendar with the parsed date.
     * @param cal calendar to fill
     * @param ignoreChanges if <tt>true</tt>, throw an exception when a date like <tt>Sept 31</tt> is changed to
     * <tt>Oct 1</tt>
     * @throws CalendarParserException if the date cannot be set for some reason
     */
    void setCalendar(GregorianCalendar cal, boolean ignoreChanges) throws CalendarParserException {
        cal.clear();
        if(year != UNSET && month != UNSET && day != UNSET){
            cal.set(Calendar.YEAR, year);
            cal.set(Calendar.MONTH, month - 1);
            cal.set(Calendar.DATE, day);

            if( !ignoreChanges){
                final int calYear = cal.get(Calendar.YEAR);
                final int calMonth = cal.get(Calendar.MONTH);
                final int calDay = cal.get(Calendar.DATE);

                if(calYear != year || (calMonth + 1) != month || calDay != day){
                    throw new CalendarParserException("Date was set to " + calYear + "/" + (calMonth + 1) + "/"
                                                      + calDay + " not requested " + year + "/" + month + "/" + day);
                }
            }
        }

        cal.clear(Calendar.HOUR);
        cal.clear(Calendar.MINUTE);
        cal.clear(Calendar.SECOND);
        cal.clear(Calendar.MILLISECOND);

        if(hour != UNSET && minute != UNSET){
            cal.set(Calendar.HOUR, hour);
            cal.set(Calendar.MINUTE, minute);
            if(second != UNSET){
                cal.set(Calendar.SECOND, second);
                if(milli != UNSET){
                    cal.set(Calendar.MILLISECOND, milli);
                }
            }

            if(timeZone != null){
                cal.setTimeZone(timeZone);
            }
        }
    }

    /**
     * Set the day of month value.
     * @param val day of month value
     * @throws CalendarParserException if the value is not a valid day of month
     */
    void setDate(int val) throws CalendarParserException {
        if(val < 1 || val > 31){
            throw new CalendarParserException("Bad day " + val);
        }

        day = val;
    }

    /**
     * Set the hour value.
     * @param val hour value
     * @throws CalendarParserException if the value is not a valid hour
     */
    void setHour(int val) throws CalendarParserException {
        final int tmpHour;
        if(timePostMeridian){
            tmpHour = val + 12;
            timePostMeridian = false;
        }
        else{
            tmpHour = val;
        }

        if(tmpHour < 0 || tmpHour > 23){
            throw new CalendarParserException("Bad hour " + val);
        }

        hour = tmpHour;
    }

    /**
     * Set the millisecond value.
     * @param val millisecond value
     * @throws CalendarParserException if the value is not a valid millisecond
     */
    void setMillisecond(int val) throws CalendarParserException {
        if(val < 0 || val > 999){
            throw new CalendarParserException("Bad millisecond " + val);
        }

        milli = val;
    }

    /**
     * Set the minute value.
     * @param val minute value
     * @throws CalendarParserException if the value is not a valid minute
     */
    void setMinute(int val) throws CalendarParserException {
        if(val < 0 || val > 59){
            throw new CalendarParserException("Bad minute " + val);
        }

        minute = val;
    }

    /**
     * Set the month value.
     * @param val month value
     * @throws CalendarParserException if the value is not a valid month
     */
    void setMonth(int val) throws CalendarParserException {
        if(val < 1 || val > 12){
            throw new CalendarParserException("Bad month " + val);
        }

        month = val;
    }

    /**
     * Set the second value.
     * @param val second value
     * @throws CalendarParserException if the value is not a valid second
     */
    void setSecond(int val) throws CalendarParserException {
        if(val < 0 || val > 59){
            throw new CalendarParserException("Bad second " + val);
        }

        second = val;
    }

    /**
     * Set the AM/PM indicator value.
     * @param val <tt>true</tt> if time represented is after noon
     */
    void setTimePostMeridian(boolean val) {
        timePostMeridian = val;
    }

    /**
     * Set the time zone.
     * @param tz time zone
     */
    void setTimeZone(TimeZone tz) {
        timeZone = tz;
    }

    /**
     * Set the year value.
     * @param val year value
     * @throws CalendarParserException if the value is not a valid year
     */
    void setYear(int val) throws CalendarParserException {
        if(val < 0){
            throw new CalendarParserException("Bad year " + val);
        }

        year = val;
    }
}

/**
 * A parser for arbitrary date/time strings.
 */
public class CalendarParser {
    private static final Logger logger = Logger.getLogger(CalendarParser.class);
    /** bit indicating that the year comes before the month. */
    public static final int YEAR_BEFORE_MONTH = ParserState.YEAR_BEFORE_MONTH;
    /** bit indicating that the year comes before the day. */
    public static final int YEAR_BEFORE_DAY = ParserState.YEAR_BEFORE_DAY;
    /** bit indicating that the month comes before the day. */
    public static final int MONTH_BEFORE_DAY = ParserState.MONTH_BEFORE_DAY;

    /** bit indicating that the year comes after the month. */
    public static final int YEAR_AFTER_MONTH = ParserState.YEAR_AFTER_MONTH;
    /** bit indicating that the year comes after the day. */
    public static final int YEAR_AFTER_DAY = ParserState.YEAR_AFTER_DAY;
    /** bit indicating that the month comes after the day. */
    public static final int MONTH_AFTER_DAY = ParserState.MONTH_AFTER_DAY;

    /** day/month/year order. */
    public static final int DD_MM_YY = YEAR_AFTER_MONTH | YEAR_AFTER_DAY | MONTH_AFTER_DAY;
    /** month/day/year order. */
    public static final int MM_DD_YY = YEAR_AFTER_MONTH | YEAR_AFTER_DAY | MONTH_BEFORE_DAY;
    /** month/year/day order. */
    public static final int MM_YY_DD = YEAR_AFTER_MONTH | YEAR_BEFORE_DAY | MONTH_BEFORE_DAY;
    /** day/year/month order. */
    public static final int DD_YY_MM = YEAR_BEFORE_MONTH | YEAR_AFTER_DAY | MONTH_AFTER_DAY;
    /** year/day/month order. */
    public static final int YY_DD_MM = YEAR_BEFORE_MONTH | YEAR_BEFORE_DAY | MONTH_AFTER_DAY;
    /** year/month/day order. */
    public static final int YY_MM_DD = YEAR_BEFORE_MONTH | YEAR_BEFORE_DAY | MONTH_BEFORE_DAY;

    /** list of time zone names. */
    private static final String[] zoneNames = loadTimeZoneNames();

    /** Unknown place in time parsing. */
    private static final int PLACE_UNKNOWN = 0;
    /** Parsing hour value from time string. */
    private static final int PLACE_HOUR = 1;
    /** Parsing minute value from time string. */
    private static final int PLACE_MINUTE = 2;
    /** Parsing second value from time string. */
    private static final int PLACE_SECOND = 3;
    /** Parsing millisecond value from time string. */
    private static final int PLACE_MILLI = 4;

    /** Adjustment for two-digit years will break in 2050. */
    private static final int CENTURY_OFFSET = 2000;

    /** value indicating an unset variable. */
    private static final int UNSET = ParserState.UNSET;

    /** list of weekday names. */
    private static final String[] WEEKDAY_NAMES = {"sunday", "monday", "tuesday", "wednesday", "thursday", "friday",
                                                   "saturday",};

    /** list of month abbreviations and names. */
    private static final String[][] MONTHS = { {"jan", "January"}, {"feb", "February"}, {"mar", "March"},
                                              {"apr", "April"}, {"may", "May"}, {"jun", "June"}, {"jul", "July"},
                                              {"aug", "August"}, {"sep", "September"}, {"oct", "October"},
                                              {"nov", "November"}, {"dec", "December"},};

    /**
     * Append formatted time string to the string buffer.
     * @param buf string buffer
     * @param cal object containing time
     * @param needSpace <tt>true</tt> if a space character should be inserted before any data
     */
    private static final void appendTimeString(StringBuffer buf, Calendar cal, boolean needSpace) {
        final int hour = cal.get(Calendar.HOUR_OF_DAY);
        final int minute = cal.get(Calendar.MINUTE);
        final int second = cal.get(Calendar.SECOND);
        final int milli = cal.get(Calendar.MILLISECOND);

        if(hour != 0 || minute != 0 || second != 0 || milli != 0){
            if(needSpace){
                buf.append(' ');
            }
            if(hour < 10){
                buf.append(' ');
            }
            buf.append(hour);

            if(minute < 10){
                buf.append(":0");
            }
            else{
                buf.append(':');
            }
            buf.append(minute);

            if(second != 0 || milli != 0){
                if(second < 10){
                    buf.append(":0");
                }
                else{
                    buf.append(':');
                }
                buf.append(second);

                if(milli != 0){
                    if(milli < 10){
                        buf.append(".00");
                    }
                    else if(milli < 100){
                        buf.append(".0");
                    }
                    else{
                        buf.append('.');
                    }
                    buf.append(milli);
                }
            }
        }

        TimeZone tz = cal.getTimeZone();
        if(tz.getRawOffset() == 0){
            buf.append(" GMT");
        }
        else{
            buf.append(' ');

            int offset = tz.getRawOffset() / (60 * 1000);
            if(offset < 0){
                buf.append('-');
                offset = -offset;
            }
            else{
                buf.append('+');
            }

            int hrOff = offset / 60;
            if(hrOff < 10){
                buf.append('0');
            }
            buf.append(hrOff);
            buf.append(':');

            int minOff = offset % 60;
            if(minOff < 10){
                buf.append('0');
            }
            buf.append(minOff);
        }
    }

    /**
     * Return a string representation of the order value.
     * @param order order
     * @return order string
     */
    public static final String getOrderString(int order) {
        switch(order){
            case DD_MM_YY:
                return "DD_MM_YY";
            case MM_DD_YY:
                return "MM_DD_YY";
            case MM_YY_DD:
                return "MM_YY_DD";
            case DD_YY_MM:
                return "DD_YY_MM";
            case YY_DD_MM:
                return "YY_DD_MM";
            case YY_MM_DD:
                return "YY_MM_DD";
            default:
                break;
        }

        return "??" + order + "??";
    }

    /**
     * Translate a string representation of an ordinal number to the appropriate numeric value.<br>
     * For example, <tt>"1st"</tt> would return <tt>1</tt>, <tt>"23rd"</tt> would return <tt>23</tt>, etc.
     * @param str ordinal string
     * @return the numeric value of the ordinal number, or <tt>CalendarParser.UNSET</tt> if the supplied string is not
     * a valid ordinal number.
     */
    private static final int getOrdinalNumber(String str) {
        final int len = (str == null ? 0 : str.length());
        if(len >= 3){

            String suffix = str.substring(len - 2);
            if(suffix.equalsIgnoreCase("st") || suffix.equalsIgnoreCase("nd") || suffix.equalsIgnoreCase("rd")
               || suffix.equalsIgnoreCase("th")){
                try{
                    return Integer.parseInt(str.substring(0, len - 2));
                }
                catch(NumberFormatException nfe){
                    // fall through if number was not parsed
                }
            }
        }

        return UNSET;
    }

    /**
     * Get name of current place in time.
     * @param place place ID
     * @return place name (<tt>"hour"</tt>, <tt>"minute"</tt>, etc.
     */
    private static final String getTimePlaceString(int place) {
        switch(place){
            case PLACE_HOUR:
                return "hour";
            case PLACE_MINUTE:
                return "minute";
            case PLACE_SECOND:
                return "second";
            case PLACE_MILLI:
                return "millisecond";
            default:
                break;
        }

        return "unknown";
    }

    /**
     * Determine is the supplied string is a value weekday name.
     * @param str weekday name to check
     * @return <tt>true</tt> if the supplied string is a weekday name.
     */
    private static final boolean isWeekdayName(String str) {
        if(str == null || str.length() < 3){
            return false;
        }

        String lstr = str.toLowerCase();
        for(int i = 0; i < WEEKDAY_NAMES.length; i++){
            if(lstr.startsWith(WEEKDAY_NAMES[i]) || WEEKDAY_NAMES[i].toLowerCase().startsWith(lstr)){
                return true;
            }
        }

        return false;
    }

    /**
     * Load list of time zones if sun.util.calendar.ZoneInfo exists.
     * @return <tt>null</tt> if time zone list cannot be loaded.
     */
    private static final String[] loadTimeZoneNames() {
        Class zoneInfo;
        try{
            zoneInfo = Class.forName("sun.util.calendar.ZoneInfo");
        }
        catch(ClassNotFoundException cnfe){
            return null;
        }

        Method method;
        try{
            method = zoneInfo.getDeclaredMethod("getAvailableIDs", new Class[0]);
        }
        catch(NoSuchMethodException nsme){
            return null;
        }

        Object result;
        try{
            result = method.invoke(null, null);
        }
        catch(IllegalAccessException iae){
            return null;
        }
        catch(InvocationTargetException ite){
            return null;
        }

        String[] tmpList = (String[])result;

        int numSaved = 0;
        String[] finalList = null;

        for(int i = 0; i < 2; i++){
            if(i > 0){
                if(numSaved == 0){
                    return null;
                }

                finalList = new String[numSaved];
                numSaved = 0;
            }

            for(int j = 0; j < tmpList.length; j++){
                final int len = tmpList[j].length();
                if((len > 2 && Character.isUpperCase(tmpList[j].charAt(1)))
                   && (len != 7 || !Character.isDigit(tmpList[j].charAt(3)))){
                    if(finalList == null){
                        numSaved++;
                    }
                    else{
                        finalList[numSaved++] = tmpList[j];
                    }

                    if(len == 3 && tmpList[j].charAt(1) == 'S' && tmpList[j].charAt(2) == 'T'){
                        if(finalList == null){
                            numSaved++;
                        }
                        else{
                            StringBuffer dst = new StringBuffer();
                            dst.append(tmpList[j].charAt(0));
                            dst.append("DT");
                            finalList[numSaved++] = dst.toString();
                        }
                    }
                }
            }
        }

        return finalList;
    }

    /**
     * Convert the supplied month name to its numeric representation. <br>
     * For example, <tt>"January"</tt> (or any substring) would return <tt>1</tt> and <tt>"December"</tt> would
     * return <tt>12</tt>.
     * @param str month name
     * @return the numeric month, or <tt>CalendarParser.UNSET</tt> if the supplied string is not a valid month name.
     */
    public static int monthNameToNumber(String str) {
        if(str != null && str.length() >= 3){
            String lstr = str.toLowerCase();
            for(int i = 0; i < MONTHS.length; i++){
                if(lstr.startsWith(MONTHS[i][0]) || MONTHS[i][1].toLowerCase().startsWith(lstr)){
                    return i + 1;
                }
            }
        }

        return UNSET;
    }

    /**
     * Extract a date from a string, defaulting to YY-MM-DD order for all-numeric strings.
     * @param dateStr date string
     * @return parsed date
     * @throws CalendarParserException if there was a problem parsing the string.
     */
    public static final Calendar parse(String dateStr) throws CalendarParserException {
        return parse(dateStr, YY_MM_DD);
    }

    /**
     * Extract a date from a string.
     * @param dateStr date string
     * @param order order in which pieces of numeric strings are assigned (should be one of <tt>YY_MM_DD</tt>,
     * <tt>MM_DD_YY</tt>, etc.)
     * @return parsed date
     * @throws CalendarParserException if there was a problem parsing the string.
     */
    public static final Calendar parse(String dateStr, int order) throws CalendarParserException {
        return parse(dateStr, order, true);
    }

    /**
     * Extract a date from a string.
     * @param dateStr date string
     * @param order order in which pieces of numeric strings are assigned (should be one of <tt>YY_MM_DD</tt>,
     * <tt>MM_DD_YY</tt>, etc.)
     * @param ignoreChanges if <tt>true</tt>, ignore date changes such as <tt>Feb 31</tt> being changed to
     * <tt>Mar 3</tt>.
     * @return parsed date
     * @throws CalendarParserException if there was a problem parsing the string.
     */
    public static final Calendar parse(String dateStr, int order, boolean ignoreChanges) throws CalendarParserException {
        if(dateStr == null){
            return null;
        }

        return parseString(dateStr, order, ignoreChanges);
    }

    /**
     * Parse a non-numeric token from the date string.
     * @param dateStr full date string
     * @param state parser state
     * @param token string being parsed
     * @throws CalendarParserException if there was a problem parsing the token
     */
    private static final void parseNonNumericToken(String dateStr, ParserState state, String token)
                    throws CalendarParserException {
        // if it's a weekday name, ignore it

        if(isWeekdayName(token)){
            logger.info("IGNORE \"" + token + "\" (weekday)");
            return;
        }
        
        //added by Bill Studer Aug 2, 2008
        //Let's ignore the UTC string in dates such as
        //Thu, 14 Feb 2008 21:14:35 +0000 (UTC)
        else if(token.equalsIgnoreCase("(UTC)")){
            logger.info("IGNORE \"" + token + "\" (UTC)");
            return;
        }

        // if it looks like a time, deal with it
        if(token.indexOf(':') > 0){
            final char firstChar = token.charAt(0);
            if(Character.isDigit(firstChar)){
                parseTime(dateStr, state, token);
                return;
            }
            else if(firstChar == '+' || firstChar == '-'){
                parseTimeZoneOffset(dateStr, state, token);
                return;
            }
            else{
                throw new CalendarParserException("Unrecognized time \"" + token + "\" in date \"" + dateStr + "\"");
            }
        }

        //Added Bill Studer on Aug 2, 2008
        //try to parse a timezone in format [+-]dddd OR [+-]ddd (e.g. +0200 OR +200 is two hours ahead of GMT)
        if(token.matches("[+-]\\d{3,4}")){
            logger.debug(String.format("Token: %s on %s matches non-coloned timezone offset: ", token, dateStr));
            //if in format of [+-]ddd we assume first digit is hour, second & third digits are minutes
            String hours = null;
            String minutes = null;
            if(token.length() == 4){
                hours = token.substring(0,2);
                minutes = token.substring(2,4);
            }
            else {
                hours = token.substring(0,3);
                minutes = token.substring(3,5);
            }
            String newToken = hours + ":" + minutes;
            logger.debug("Parsing new token: " + newToken);
            parseTimeZoneOffset(dateStr, state, newToken);
            return;
        }

        // try to parse month name
        int tmpMon = monthNameToNumber(token);

        // if token isn't a month name ... PUKE
        if(tmpMon != UNSET){

            // if month number is unset, set it and move on
            if( !state.isMonthSet()){
                state.setMonth(tmpMon);
                logger.debug("MONTH=" + MONTHS[state.getMonth() - 1][0] + " (" + token + ") name");
                return;
            }

            // try to move the current month value to the year or day
            if( !state.isYearSet()){
                if(state.isDateSet() || state.isYearBeforeDay()){
                    state.setYear(state.getMonth());
                    state.setMonth(tmpMon);
                    logger.debug("MONTH=" + MONTHS[state.getMonth() - 1][0] + ", YEAR=" + state.getYear() + " (" + token + ") name swap");
                }
                else{
                    state.setDate(state.getMonth());
                    state.setMonth(tmpMon);
                    logger.debug("MONTH=" + MONTHS[state.getMonth() - 1][0] + ", DAY=" + state.getDate() + " (" + token + ") name swap");
                }
                return;
            }

            // year was already set, so try to move month value to day
            if( !state.isDateSet()){
                state.setDate(state.getMonth());
                state.setMonth(tmpMon);
                    logger.debug("MONTH=" + MONTHS[state.getMonth() - 1][0] + ", DAY=" + state.getDate() + " (" + token + ") name swap 2");
                return;
            }

            // can't move month value to year or day ... PUKE
            logger.debug("*** Too many numbers in \"" + dateStr + "\"");
            throw new CalendarParserException("Too many numbers in" + " date \"" + dateStr + "\"");
        }

        // maybe it's an ordinal number list "1st", "23rd", etc.
        int val = getOrdinalNumber(token);
        if(val == UNSET){
            final String lToken = token.toLowerCase();

            if(lToken.equals("am")){
                // don't need to do anything
                logger.debug("TIME=AM (" + token + ")");
                return;
            }
            else if(lToken.equals("pm")){
                if( !state.isHourSet()){
                    state.setTimePostMeridian(true);
                }
                else{
                    state.setHour(state.getHour() + 12);
                }
                logger.debug("TIME=PM (" + token + ")");
                return;
            }
            else if(zoneNames != null){
                // maybe it's a time zone name
                for(int z = 0; z < zoneNames.length; z++){
                    if(token.equalsIgnoreCase(zoneNames[z])){
                        TimeZone tz = TimeZone.getTimeZone(token);
                        if(tz.getRawOffset() != 0 || lToken.equals("gmt")){
                            state.setTimeZone(tz);
                            return;
                        }
                    }
                }
            }
            logger.debug("*** Unknown string \"" + token + "\"");
            throw new CalendarParserException("Unknown string \"" + token + "\" in date \"" + dateStr + "\"");
        }

        // if no day yet, we're done
        if( !state.isDateSet()){
            state.setDate(val);
            logger.debug("DAY=" + state.getDate() + " (" + token + ") ord");
            return;
        }

        // if either year or month is unset...
        if( !state.isYearSet() || !state.isMonthSet()){

            // if day can't be a month, shift it into year
            if(state.getDate() > 12){
                if( !state.isYearSet()){
                    state.setYear(state.getDate());
                    state.setDate(val);
                    logger.debug("YEAR=" + state.getYear() + ", DAY=" + state.getDate() + " (" + token + ") ord>12 swap");
                    return;
                }

                // year was already set, maybe we can move it to month
                if(state.getYear() <= 12){
                    state.setMonth(state.getYear());
                    state.setYear(state.getDate());
                    state.setDate(val);
                    logger.debug("YEAR=" + state.getYear() + ", MONTH=" + state.getMonth() + ", DAY=" + state.getDate() + " (" + token + ") ord megaswap");
                    return;
                }

                // try to shift day value to either year or month
            }
            else if( !state.isYearSet()){
                if( !state.isMonthSet() && !state.isYearBeforeMonth()){
                    state.setMonth(state.getDate());
                    state.setDate(val);
                    logger.debug("MONTH=" + state.getMonth() + ", DAY=" + state.getDate() + " (" + token + ") ord swap");
                    return;
                }

                state.setYear(state.getDate());
                state.setDate(val);
                logger.debug("YEAR=" + state.getYear() + ", DAY=" + state.getDate() + " (" + token
                                       + ") ord swap");
                return;

                // year was set, so we know month is unset
            }
            else{

                state.setMonth(state.getDate());
                state.setDate(val);
                logger.debug("MONTH=" + state.getMonth() + ", DAY=" + state.getDate() + " (" + token + ") ord swap#2");
                return;
            }
        }

        logger.debug("*** Extra number \"" + token + "\"");
        throw new CalendarParserException("Cannot assign ordinal in \"" + dateStr + "\"");
    }

    /**
     * Split a large numeric value into a year/month/date values.
     * @param dateStr full date string
     * @param state parser state
     * @param val numeric value to use
     * @throws CalendarParserException if there was a problem splitting the value
     */
    private static final void parseNumericBlob(String dateStr, ParserState state, int val)
                    throws CalendarParserException {
        if(state.isYearSet() || state.isMonthSet() || state.isDateSet()){
            throw new CalendarParserException("Unknown value " + val + " in date \"" + dateStr + "\"");
        }

        int tmpVal = val;
        if(state.isYearBeforeMonth()){
            if(state.isYearBeforeDay()){
                final int last = tmpVal % 100;
                tmpVal /= 100;

                final int middle = tmpVal % 100;
                tmpVal /= 100;

                state.setYear(tmpVal);
                if(state.isMonthBeforeDay()){
                    // YYYYMMDD
                    state.setMonth(middle);
                    state.setDate(last);
                }
                else{
                    // YYYYDDMM
                    state.setDate(middle);
                    state.setMonth(last);
                }
            }
            else{
                // DDYYYYMM
                state.setMonth(tmpVal % 100);
                tmpVal /= 100;

                state.setYear(tmpVal % 10000);
                tmpVal /= 10000;

                state.setDate(tmpVal);
            }
        }
        else if(state.isYearBeforeDay()){
            // MMYYYYDD
            state.setDate(tmpVal % 100);
            tmpVal /= 100;

            state.setYear(tmpVal % 10000);
            tmpVal /= 10000;

            state.setMonth(tmpVal);
        }
        else{
            state.setYear(tmpVal % 10000);
            tmpVal /= 10000;

            final int middle = tmpVal % 100;
            tmpVal /= 100;
            if(state.isMonthBeforeDay()){
                // MMDDYYYY
                state.setDate(middle);
                state.setMonth(tmpVal);
            }
            else{
                // DDMMYYYY
                state.setDate(tmpVal);
                state.setMonth(middle);
            }
        }
        logger.debug("YEAR=" + state.getYear() + " MONTH=" + state.getMonth() + " DAY=" + state.getDate() + " (" + val + ") blob");
    }

    /**
     * Use a numeric token from the date string.
     * @param dateStr full date string
     * @param state parser state
     * @param val numeric value to use
     * @throws CalendarParserException if there was a problem parsing the token
     */
    private static final void parseNumericToken(String dateStr, ParserState state, int val)
                    throws CalendarParserException {
        // puke if we've already found 3 values
        if(state.isYearSet() && state.isMonthSet() && state.isDateSet()){
            logger.debug("*** Extra number " + val);
            throw new CalendarParserException("Extra value \"" + val + "\" in date \"" + dateStr + "\"");
        }

        // puke up on negative numbers
        if(val < 0){
            logger.debug("*** Negative number " + val);
            throw new CalendarParserException("Found negative number in" + " date \"" + dateStr + "\"");
        }

        if(val > 9999){
            parseNumericBlob(dateStr, state, val);
            return;
        }

        // deal with obvious years first
        if(val > 31){

            // if no year yet, assign it and move on
            if( !state.isYearSet()){
                state.setYear(val);
                logger.debug("YEAR=" + state.getYear() + " (" + val + ") >31");
                return;
            }

            // puke if the year value can't possibly be a day or month
            if(state.getYear() > 31){
                logger.debug("*** Ambiguous year " + state.getYear() + " vs. " + val);
                String errMsg = "Couldn't decide on year number in date \"" + dateStr + "\"";
                throw new CalendarParserException(errMsg);
            }

            // if the year value can't be a month...
            if(state.getYear() > 12){

                // if day isn't set, use old val as day and new val as year
                if( !state.isDateSet()){
                    state.setDate(state.getYear());
                    state.setYear(val);
                    logger.debug("YEAR=" + state.getYear() + ", DAY=" + state.getDate() + " (" + val + ") >31 swap");
                    return;
                }

                // NOTE: both day and year are set

                // try using day value as month so we can move year
                // value to day and use new value as year
                if(state.getDate() <= 12){
                    state.setMonth(state.getDate());
                    state.setDate(state.getYear());
                    state.setYear(val);
                    logger.debug("YEAR=" + state.getYear() + ", MONTH=" + state.getMonth() + ", DAY=" + state.getDate() + " (" + val + ") >31 megaswap");
                    return;
                }
                logger.debug("*** Unassignable year-like" + " number " + val);
                throw new CalendarParserException("Bad number " + val + " found in date \"" + dateStr + "\"");
            }

            // NOTE: year <= 12

            if( !state.isDateSet() && !state.isMonthSet()){
                if(state.isMonthBeforeDay()){
                    state.setMonth(state.getYear());
                    state.setYear(val);
                    logger.debug("YEAR=" + state.getYear() + ", MONTH=" + state.getMonth() + " (" + val+ ") >31 swap");
                }
                else{
                    state.setDate(state.getYear());
                    state.setYear(val);
                    logger.debug("YEAR=" + state.getYear() + ", DAY=" + state.getDate() + " (" + val+ ") >31 swap#2");
                }
                return;
            }

            if( !state.isDateSet()){
                state.setDate(state.getYear());
                state.setYear(val);
                logger.debug("YEAR=" + state.getYear() + ", DAY=" + state.getDate() + " (" + val+ ") >31 day swap");
                return;
            }

            // assume this was a mishandled month
            state.setMonth(state.getYear());
            state.setYear(val);
            logger.debug("YEAR=" + state.getYear() + ", MONTH=" + state.getMonth() + " (" + val+ ") >31 mon swap");
            return;
        }

        // now deal with non-month values
        if(val > 12){

            // if no year value yet...
            if( !state.isYearSet()){

                // if the day is set, or if we assign year before day...
                if(state.isDateSet() || state.isYearBeforeDay()){
                    state.setYear(val);
                    logger.debug("YEAR=" + state.getYear() + " (" + val + ") >12");
                }
                else{
                    state.setDate(val);
                    logger.debug("DAY=" + state.getDate() + " (" + val + ") >12");
                }

                return;
            }

            // NOTE: year is set

            // if no day value yet, assign it and move on
            if( !state.isDateSet()){
                state.setDate(val);
                logger.debug("DAY=" + state.getDate() + " (" + val + ") >12 !yr");
                return;
            }

            // NOTE: both year and day are set

            // XXX see if we can shift things around
            logger.debug("*** Unassignable year/day number " + val);
            throw new CalendarParserException("Bad number " + val + " found in date \"" + dateStr + "\"");
        }

        // NOTE: ambiguous value

        // if year is set, this must be either the month or day
        if(state.isYearSet()){
            if(state.isMonthSet() || ( !state.isDateSet() && !state.isMonthBeforeDay())){
                state.setDate(val);
                logger.debug("DAY=" + state.getDate() + " (" + val + ") ambig!yr");
            }
            else{
                state.setMonth(val);
                logger.debug("MONTH=" + state.getMonth() + " (" + val + ") ambig!yr");
            }

            return;
        }

        // NOTE: year not set

        // if month is set, this must be either the year or day
        if(state.isMonthSet()){
            if(state.isDateSet() || state.isYearBeforeDay()){
                state.setYear(val);
                logger.debug("YEAR=" + state.getYear() + " (" + val + ") ambig!mo");
            }
            else{
                state.setDate(val);
                logger.debug("DAY=" + state.getDate() + " (" + val + ") ambig!mo");
            }

            return;
        }

        // NOTE: neither year nor month is set

        // if day is set, this must be either the year or month
        if(state.isDateSet()){
            if(state.isYearBeforeMonth()){
                state.setYear(val);
                logger.debug("YEAR=" + state.getYear() + " (" + val + ") ambig!day");
            }
            else{
                state.setMonth(val);
                logger.debug("MONTH=" + state.getMonth() + " (" + val + ") ambig!day");
            }
            return;
        }

        // NOTE: no value set yet
        if(state.isYearBeforeMonth()){
            if(state.isYearBeforeDay()){
                state.setYear(val);
                logger.debug("YEAR=" + state.getYear() + " (" + val + ") YM|YD");
            }
            else{
                state.setDate(val);
                logger.debug("DAY=" + state.getDate() + " (" + val + ") YM!YD");
            }
        }
        else if(state.isMonthBeforeDay()){
            state.setMonth(val);
            logger.debug("MONTH=" + state.getMonth() + " (" + val + ") !YM|MD");
        }
        else{
            state.setDate(val);
            logger.debug("DAY=" + state.getDate() + " (" + val + ") !YM!MD");
        }
    }

    /**
     * Extract a date from the supplied string.
     * @param dateStr string to parse
     * @param order year/month/day order (YY_MM_DD, MM_DD_YY, etc.)
     * @param ignoreChanges if <tt>true</tt>, ignore date changes such as <tt>Feb 31</tt> being changed to
     * <tt>Mar 3</tt>.
     * @return parsed date
     * @throws CalendarParserException if no valid date was found.
     */
    private static final Calendar parseString(String dateStr, int order, boolean ignoreChanges)
                    throws CalendarParserException {
        ParserState state = new ParserState(order);

        Pattern pat = Pattern.compile("([\\s/,]+|(\\S)\\-)");
        Matcher matcher = pat.matcher(dateStr);

        int prevEnd = 0;
        while(prevEnd < dateStr.length()){
            String token;
            if( !matcher.find()){
                token = dateStr.substring(prevEnd);
                prevEnd = dateStr.length();
            }
            else{
                final boolean isMinus = (matcher.groupCount() == 2 && matcher.group(2) != null);

                if( !isMinus){
                    token = dateStr.substring(prevEnd, matcher.start());
                }
                else{
                    token = dateStr.substring(prevEnd, matcher.start()) + matcher.group(2);
                }

                prevEnd = matcher.end();
            }

            logger.debug("YEAR " + (state.isYearSet() ? Integer.toString(state.getYear()) : "UNSET")
                            + ", MONTH " + (state.isMonthSet() ? Integer.toString(state.getMonth()) : "UNSET")
                            + ", DAY " + (state.isDateSet() ? Integer.toString(state.getDate()) : "UNSET")
                            + ", TOKEN=\"" + token + "\"");
            // try to decipher next token as a number
            try{
                final int val = Integer.parseInt(token);
                parseNumericToken(dateStr, state, val);
            }
            catch(NumberFormatException e){
                parseNonNumericToken(dateStr, state, token);
            }
        }

        // before checking for errors, check for missing year
        if( !state.isDateSet() && state.getYear() <= 31){
            int tmp = state.getDate();
            state.setDate(state.getYear());
            state.setYear(tmp);
        }

        if( !state.isDateSet()){
            if( !state.isMonthSet()){
                if( !state.isYearSet()){
                    throw new CalendarParserException("No date found in \"" + dateStr + "\"");
                }
                else{
                    throw new CalendarParserException("Day and month missing" + " from \"" + dateStr + "\"");
                }
            }
            else{
                throw new CalendarParserException("Day missing from \"" + dateStr + "\"");
            }
        }
        else if( !state.isMonthSet()){
            if( !state.isYearSet()){
                throw new CalendarParserException("Year and month missing" + " from \"" + dateStr + "\"");
            }
            else{
                throw new CalendarParserException("Month missing from \"" + dateStr + "\"");
            }
        }
        else if( !state.isYearSet()){
            throw new CalendarParserException("Year missing from \"" + dateStr + "\"");
        }

        final int tmpYear = state.getYear();
        if(tmpYear < 50){
            state.setYear(tmpYear + CENTURY_OFFSET);
        }
        else if(tmpYear < 100){
            state.setYear(tmpYear + (CENTURY_OFFSET - 100));
        }

        GregorianCalendar cal = new GregorianCalendar();

        state.setCalendar(cal, ignoreChanges);

        logger.debug("Y" + state.getYear() + " M" + state.getMonth() + " D" + state.getDate() + " H"
                        + state.getHour() + " M" + state.getMinute() + " S" + state.getSecond() + " L"
                        + state.getMillisecond() + " => " + toString(cal));
        return cal;
    }

    /**
     * Parse a time string.
     * @param dateStr full date string
     * @param state parser state
     * @param timeStr string containing colon-separated time
     * @throws CalendarParserException if there is a problem with the time
     */
    private static final void parseTime(String dateStr, ParserState state, String timeStr)
                    throws CalendarParserException {
        int place = PLACE_HOUR;

        String tmpTime;

        final char lastChar = timeStr.charAt(timeStr.length() - 1);
        if(lastChar != 'm' && lastChar != 'M'){
            logger.debug("No AM/PM in \"" + timeStr + "\" (time)");
            tmpTime = timeStr;
        }
        else{
            final char preLast = timeStr.charAt(timeStr.length() - 2);
            if(preLast == 'a' || preLast == 'A'){
                state.setTimePostMeridian(false);
            }
            else if(preLast == 'p' || preLast == 'P'){
                state.setTimePostMeridian(true);
            }
            else{
                throw new CalendarParserException("Bad time \"" + timeStr + "\" in date \"" + dateStr + "\"");
            }

            tmpTime = timeStr.substring(0, timeStr.length() - 2);
            logger.debug("Found " + (state.isTimePostMeridian() ? "PM" : "AM") + ". now \"" + tmpTime
                                   + "\" (time)");
        }

        String[] tList = tmpTime.split("[:\\.]");
        for(int i = 0; i < tList.length; i++){
            String token = tList[i];
            logger.debug("HOUR " + (state.isHourSet() ? Integer.toString(state.getHour()) : "UNSET")
                            + ", MINUTE "
                            + (state.isMinuteSet() ? Integer.toString(state.getMinute()) : "UNSET")
                            + ", SECOND "
                            + (state.isSecondSet() ? Integer.toString(state.getSecond()) : "UNSET")
                            + ", MILLISECOND "
                            + (state.isMillisecondSet() ? Integer.toString(state.getMillisecond()) : "UNSET")
                            + ", TOKEN=\"" + token + "\"");
            final int val;
            try{
                val = Integer.parseInt(token);
            }
            catch(NumberFormatException nfe){
                throw new CalendarParserException("Bad " + getTimePlaceString(place) + " string \"" + token
                                                  + "\" in \"" + dateStr + "\"");
            }

            switch(place){
                case PLACE_HOUR:
                    try{
                        state.setHour(val);
                    }
                    catch(CalendarParserException dfe){
                        throw new CalendarParserException(dfe.getMessage() + " in \"" + dateStr + "\"");
                    }
                    logger.debug("Set hour to " + val);
                    place = PLACE_MINUTE;
                    break;
                case PLACE_MINUTE:
                    try{
                        state.setMinute(val);
                    }
                    catch(CalendarParserException dfe){
                        throw new CalendarParserException(dfe.getMessage() + " in \"" + dateStr + "\"");
                    }
                    logger.debug("Set minute to " + val);
                    place = PLACE_SECOND;
                    break;
                case PLACE_SECOND:
                    try{
                        state.setSecond(val);
                    }
                    catch(CalendarParserException dfe){
                        throw new CalendarParserException(dfe.getMessage() + " in \"" + dateStr + "\"");
                    }
                    logger.debug("Set second to " + val);
                    place = PLACE_MILLI;
                    break;
                case PLACE_MILLI:
                    try{
                        state.setMillisecond(val);
                    }
                    catch(CalendarParserException dfe){
                        throw new CalendarParserException(dfe.getMessage() + " in \"" + dateStr + "\"");
                    }
                    logger.debug("Set millisecond to " + val);
                    
                    place = PLACE_UNKNOWN;
                    break;
                default:
                    throw new CalendarParserException("Unexpected place value " + place);
            }
        }
    }

    /**
     * Parse a time zone offset string.
     * @param dateStr full date string
     * @param state parser state
     * @param zoneStr string containing colon-separated time zone offset
     * @throws CalendarParserException if there is a problem with the time
     */
    private static final void parseTimeZoneOffset(String dateStr, ParserState state, String zoneStr)
                    throws CalendarParserException {
        int place = PLACE_HOUR;

        final boolean isNegative = (zoneStr.charAt(0) == '-');
        if( !isNegative && zoneStr.charAt(0) != '+'){
            throw new CalendarParserException("Bad time zone offset \"" + zoneStr + "\" in date \"" + dateStr + "\"");
        }

        int hour = UNSET;
        int minute = UNSET;

        String[] tList = zoneStr.substring(1).split(":");
        for(int i = 0; i < tList.length; i++){
            String token = tList[i];
            logger.debug("TZ_HOUR " + (hour != UNSET ? Integer.toString(hour) : "UNSET") + ", TZ_MINUTE "
                            + (minute != UNSET ? Integer.toString(minute) : "UNSET") + ", TOKEN=\"" + token
                            + "\"");
            final int val;
            try{
                val = Integer.parseInt(token);
            }
            catch(NumberFormatException nfe){
                throw new CalendarParserException("Bad time zone " + getTimePlaceString(place) + " offset \"" + token
                                                  + "\" in \"" + dateStr + "\"");
            }

            switch(place){
                case PLACE_HOUR:
                    hour = val;
                    logger.debug("Set time zone offset hour to " + val);
                    place = PLACE_MINUTE;
                    break;
                case PLACE_MINUTE:
                    minute = val;
                    logger.debug("Set time zone offset minute to " + val);
                    place = PLACE_UNKNOWN;
                    break;
                default:
                    throw new CalendarParserException("Unexpected place value " + place);
            }
        }

        String customID = "GMT" + (isNegative ? "-" : "+") + hour + ":" + (minute < 10 ? "0" : "") + minute;

        state.setTimeZone(TimeZone.getTimeZone(customID));
    }

    /**
     * Return a printable representation of the date.
     * @param cal calendar to convert to a string
     * @return a printable string.
     */
    public static final String prettyString(Calendar cal) {
        if(cal == null){
            return null;
        }

        final int calYear = cal.get(Calendar.YEAR);
        final int calMonth = cal.get(Calendar.MONTH);
        final int calDay = cal.get(Calendar.DATE);

        boolean needSpace = false;
        StringBuffer buf = new StringBuffer();

        if(calMonth >= 0 && calMonth < MONTHS.length){
            if(needSpace){
                buf.append(' ');
            }
            buf.append(MONTHS[calMonth][1]);
            needSpace = true;
        }
        if(calDay > 0){
            if(needSpace){
                buf.append(' ');
            }
            buf.append(calDay);
            if(calYear > UNSET){
                buf.append(',');
            }
            needSpace = true;
        }
        if(calYear > UNSET){
            if(needSpace){
                buf.append(' ');
            }
            buf.append(calYear);
        }

        appendTimeString(buf, cal, needSpace);

        return buf.toString();
    }

    /**
     * Return a basic representation of the string.
     * @param cal calendar to convert to a string
     * @return the basic string.
     */
    public static final String toString(Calendar cal) {
        if(cal == null){
            return null;
        }

        final int calYear = cal.get(Calendar.YEAR);
        final int calMonth = cal.get(Calendar.MONTH);
        final int calDay = cal.get(Calendar.DATE);

        boolean needSpace = false;
        StringBuffer buf = new StringBuffer();

        if(calDay > 0){
            if(needSpace){
                buf.append(' ');
            }
            buf.append(calDay);
            needSpace = true;
        }
        if(calMonth >= 0 && calMonth < MONTHS.length){
            if(needSpace){
                buf.append(' ');
            }
            buf.append(MONTHS[calMonth][1].substring(0, 3));
            needSpace = true;
        }
        if(calYear > UNSET){
            if(needSpace){
                buf.append(' ');
            }
            buf.append(calYear);
        }

        appendTimeString(buf, cal, needSpace);

        return buf.toString();
    }

    /**
     * Return a string representation of the date suitable for use in an SQL statement.
     * @param cal calendar to convert to a string
     * @return the SQL-friendly string.
     */
    public static final String toSQLString(Calendar cal) {
        if(cal == null){
            return null;
        }

        final int calYear = cal.get(Calendar.YEAR);
        final int calMonth = cal.get(Calendar.MONTH);
        final int calDay = cal.get(Calendar.DATE);

        StringBuffer buf = new StringBuffer();

        buf.append(calYear);
        buf.append('-');
        if((calMonth + 1) < 10){
            buf.append('0');
        }
        buf.append(calMonth + 1);
        buf.append('-');
        if(calDay < 10){
            buf.append('0');
        }
        buf.append(calDay);

        appendTimeString(buf, cal, true);

        return buf.toString();
    }
}
